单元测试 - 理论
很多人是不写单元测试的,无非因为性价比太低。但我始终以为,真实原因是打开方式不正确,如果花功夫系统研究一下,或许会不一样。
这不就来了,现希望相对系统第了解单元测试的理论、常用工具、实施方法。伴随会产生系列文章。初预备三部分:
- 理论:介绍单元测试需要关注的内容,从设计上如何考虑
- 工具:介绍Spring Test、JUnit、Assert、Mock技术,相关库的使用
- 实践:基于以上两个部分的学习,重构当前的一个项目,记录过程中考虑的点。并最终给出结论。
此篇为首!
与单元测试相关的书籍,大约有这么三本,评分都不戳。
- 《单元测试的艺术》
- 《有效的单元测试》
- 《单元测试之道》
本文主要内容来自于《单元测试之道》,需要注意的是,它是2005年出版的书籍,谨慎接受其知识点。
几个概念
无论文章或书籍,时常提及几个概念,注意区分。
SDLC:Software Development Lifecycle
软件开发生命周期
TDD:Test Driven Development
测试驱动开发,以测试用例为主,编码以通过用例为主要目的。通过不断地 测试-重构 ,最终完成编码
BDD:Bebavioural Driven Development
行为驱动开发,TDD衍生出来的一种测试case开发方式。测试基于被测系统的行为,即所谓的Given-When-Then(假设 - 当 -然后),举例
- 假设用户输入了有效凭证
- 当用户点击登录按钮
- 提示登录成功信息
BDD使得开发者更加关注用户的行为。
AssertJ提供BDD方式的断言API
ATDD:Acceptance Test-Driven Development
验收测试驱动开发。从用户的角度编写。主要侧重于验证系统的功能行为。它用于验证 - 代码是否按照预期工作了。重点在验收,行为人是QA或用户
TDD、BDD、ATDD都可归属于测试驱动开发,三者主要对比关系如下
Parameters | TDD | BDD | ATDD |
---|---|---|---|
参与方 | 开发者 | 开发者, 用户, QA | 开发者, 用户, QA |
使用语言 | 编程语言,如Java、Kotlin | 自然语言。或者是DSL | 自然语言。或者是DSL |
关注点 | 单元测试 | 理解需求 | 验收测试 |
工具(过时) | JDave, Cucumber, JBehave, Spec Flow, BeanSpec, Gherkin Concordian, FitNesse | Gherkin, Dave, Cucumber, JBehave, Spec Flow, BeanSpec, Concordian | TestNG, FitNesse, EasyB, Spectacular, Concordian, Thucydides |
测试什么
提到单元测试,你肯定会想到测试边缘case,但其实不止于此。详细来说,所有需要考虑的点可以用RIGHT-BICEP表示
RIGHT
结果是否正确?
首先考虑所有的正常case,保证基本功能正常。对于这类case,如果有大量测试数据,可以考虑使用数据文件存储,测试代码读取的方式。
B[order]
所有边界条件都正确吗?
有一个方式有助于思考所有的边界条件
- Conformance - 一致性,值是否和预期一致
- Ordering - 顺序性,值是否和预期一样有序
- Range - 区间性,值是否位于最小值和最大值之间
- Reference - 依赖性,代码是否引入了一些不在代码控制范围内的资源
- Existence - 存在性,值是否存在,null、0、””、空集合等
- Cardinatity - 基数性,是否恰好有足够的数量
- Time - 时间性,所有事情的发生是否是有序的、时间是否正好
I[nverse]
反向关联如何?
运用方法的反向逻辑关系来验证它们。比如插入数据库,可以用查询数据库进行验证。
C[ross]
能用其它手段交叉检查结果吗?
计算一个量可以有多种算法,当我们实现一种算法时。可以在单元测试中用另一种看起来比较笨但正确率比较高的算法来对比验证。这就叫交叉检查。
E[rror]
是否可以强制错误条件发生?
模拟现实情况中的错误,当然不可能去拔网线,但是我们可以Mock,一些典型的错误如
- 内存耗光
- 磁盘用满
- 时钟出问题
- 网络不可用或者有问题
- 系统过载
P[erformance]
性能是否满足要求?
测试指定方法的性能,但是需要注意的是这类单元测试不能经常做,因为比较耗时,要与其它单元测试分开。
下文,我们具体来看看各细分点
边界条件
常见边界
- 逻辑边界:如角度超过360、人的年龄超过200等
- 集合:空集合、null、下标越界
引用性
- 前条件:系统必须处于什么状态下才能运行。这和一致性类似
- 后条件:方法将保证哪些状态发生。方法本身的结果,需要检查。方法产生的副作用,也是需要检查的。
比如,对数据库的操作应该就算是副作用,也需要对插入的结果进行验证。
基数性
- 基数指的是计数,是否有恰好的数量
- 关注0-1-n,其中n可能会随着数量的变化而变化
- 这个n意味着两方面
- 写代码时,需要考虑n变化的情况,不能写死
- 测试时,需要验证n的变化情况是否在代码中有考虑到
时间性
相对时间,即时间上的顺序
比如两个方法的调用需要有顺序,如果调用顺序相反,则需要有错误
比如connect() 和 read()
绝对时间,即time,钟表上的时间
时区是否对代码有所影响。遵循不同的时间规则,对时间的计算不一样。比如GMT、夏令时等
一般来说,遇到时区问题时,底层库很可能会出现问题。因此在单元测试中有必要进行测试
超时时间是否生效
并发问题
- 即多线程测试
使用Mock
单元测试的目的是一次只验证一个方法。如果方法耦合了其它因素,比如数据库、网络请求等,此时我们在验证方法之前去准备这些外部环境,不仅浪费时间,还可能因为外部环境发生变化而导致单元测试在没有修改的情况下出现失败。此时需要使用Mock进行数据模拟。
一般来说,真实对象具有如下不方便的特点,Mock可以完全解决这些问题
- 具有不确定的行为
- 很难被创建
- 某些行为很难被触发
- 可能有用户界面
日常使用的大部分东西,如数据库、网络,都有专门的Mock库,不要费劲去自己手动Mock。
何为好的单元测试
单元测试虽好,但如果使用不当,则会浪费大量时间。有一个衡量单元测试好坏的标准:A-TRIP
- Automatic - 自动化
- 测试调用自动化
- 测试结果检查自动化
- Thorough - 彻底
- 单元测试覆盖要完全
- Repeatable - 可重复
- 每个测试独立于其它测试;独立于周围的环境
- 需要能够以任意顺序可重复地执行
- Independent - 独立
- 每个测试都要有很强的针对性
- 确保一次只测试了一样东西
- 一个测试函数可以仅测试一个复杂函数的一小部分功能;该函数的测试由多个这种小的测试函数共同组成
- JUnit的@Before和@After就是针对这个问题设置的。每个test函数都会执行一次@Before和@After指定的函数
- 你绝对不能假设JUnit的单元测试函数之间的顺序
- Professional - 专业
- 不要对无关紧要的bug进行测试
- 测试的代码和正常的代码一样,都可以面向对象、遵循解耦合原则等
面向测试的设计
如TDD所说,从一开始就针对测试进行设计,具体来说:
- 通过令代码更加容易测试来改善代码的设计。甚至可能为了使代码变得容易测试而对代码进行重构
- 关注点分离。将能够测试的点和不能测试的点分离,使得该方法可测试
- 如果测试代码看起来非常丑陋,这可能就是一个不好的征兆,代码需要重新设计,直到写出易于测试的代码
书中给出了一个例子,一个GUI组件,将逻辑和GUI显示糅合在了一起,使得无法进行单元测试。解决方案是拆分出逻辑部分,对它进行单元测试。
一些陷阱
一些陷阱,还挺典型的
不写单元测试
没有单元测试的代码,bug一定会在某个时刻爆发
用冒烟测试替代单元测试
这里并不是指整个系统的冒烟测试,而是很多人写单元测试的一种方式:将包含多个流程的函数用一个单元测试搞定,忽略中间逻辑的测试,这种测试的目的是:流程是否能够跑通
这种单元测试无非在给自己营造一种虚无的安全感而已 - 你以为你测试过了,实际上并没有
只在本机跑单元测试
有可能在CI机或别人的机器上跑不了,要求在所有机器上都能跑起来
浮点数相等问题
断言浮点数相等时,要考虑精度问题
每次执行长耗时单元测试
部分单元测试耗时太长,不能每次提交CI都执行,需要将其提取出来,低频测试
代码修改导致单元测试失败
如果实现代码只修改一小部分,就导致测试代码失败,需要大量修改才能重新正确,说明被测试的代码逻辑之间耦合太深
理论上,修改一部分代码,只需要对这部分测试代码进行修改
总结
一个好的单元测试,需要考虑的绝不仅仅是边界case,还需要考虑正确性、性能、代码的优雅性。
一个好的单元测试,最终应该是能够提升开发效率,而不是带来无止尽的修改,如果这种情况出现了,考虑重构单元测试🤔。